Skip to content

add Qwen3-VL support for DFlash training#1975

Open
skierat wants to merge 2 commits into
NVIDIA:mainfrom
skierat:skierat/qwen3-vl-dflash-final
Open

add Qwen3-VL support for DFlash training#1975
skierat wants to merge 2 commits into
NVIDIA:mainfrom
skierat:skierat/qwen3-vl-dflash-final

Conversation

@skierat

@skierat skierat commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Type of change: new feature

Adds online DFlash training support for Qwen3-VL–style vision-language models.

Changes include:

  • Load VLMs through the Transformers 5 AutoModelForImageTextToText API, while retaining compatibility with the legacy VLM auto-model API.
  • Run the base model through its top-level multimodal forward when image/video inputs are present, ensuring vision embeddings are injected before collecting DFlash target hidden states.
  • Extend VisionLanguageDataCollator to:
    • propagate answer_only_loss, chat-template, and DFlash label-alignment settings;
    • apply VLM_MIN_PIXELS / VLM_MAX_PIXELS processor limits;
    • derive assistant-only masks from ChatML/Llama chat boundaries when processor generation masks are unavailable;
    • enforce the fixed training_seq_len required by DFlash block training.
  • Preserve the existing text-only DFlash path.

Usage

python -m torch.distributed.run \                                      
--nproc_per_node 4 \                                                 
examples/speculative_decoding/main.py \                              
--config modelopt_recipes/general/speculative_decoding/dflash.yaml \
model.model_name_or_path=/path/to/qwen3-vl-model \
model.trust_remote_code=true \
data.data_path=/path/to/train.jsonl \
data.vlm_processor=/path/to/qwen3-vl-model \                        
data.vlm_img_dir=/path/to/image/root \
training.training_seq_len=4096 \
training.answer_only_loss=true \
dflash.dflash_block_size=8 \
dflash.dflash_mask_token_id=151669


### Testing
- git diff --check
- Parsed all modified Python modules successfully.
- Ran iterative multi-node Slurm smoke tests with a Qwen3-VL-family model and mixed multimodal data:
    - validated VLM model loading with Transformers 5;
    - validated distributed initialization, DFlash conversion, and VLM collation paths;
    - identified and addressed processor padding/truncation behavior required by fixed-size DFlash blocks.
This PR remains draft pending a completed end-to-end training smoke test and automated regression coverage.

### Before your PR is "Ready for review"
Make sure you read and follow Contributor guidelines (https://github.com/NVIDIA/Model-Optimizer/blob/main/CONTRIBUTING.md) and your commits are signed (git commit -s -S).
Make sure you read and follow the Security Best Practices (https://github.com/NVIDIA/Model-Optimizer/blob/main/SECURITY.md#security-coding-practices-for-contributors) (e.g. avoiding hardcoded trust_remote_code=True, torch.load(...,
weights_only=False), pickle, etc.).
- Is this change backward compatible?: ✅
- If you copied code from any other sources or added a new PIP dependency, did you follow guidance in CONTRIBUTING.md: N/A
- Did you write any new necessary tests?: ❌ — automated Qwen3-VL/DFlash regression coverage still needs to be added before review.
- Did you update Changelog (https://github.com/NVIDIA/Model-Optimizer/blob/main/CHANGELOG.rst)?: ❌ — evaluate and add an entry before marking ready for review if this is considered user-facing speculative-decoding support.
- Did you get Claude approval on this PR?: N/A
### Additional Information
The PR intentionally excludes local Slurm launch scripts, logs, model paths, datasets, and environment-specific configuration.


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Improved Qwen3-VL video handling for speculative decoding with newer Transformers versions.
  * Added support for reading RoPE configuration from modern model configuration formats.
  * Improved multimodal training, label shifting, answer-only loss, and loss-mask behavior.

* **Bug Fixes**
  * Improved validation and error handling for multimodal inputs.
  * Improved training stability when no valid anchor blocks are available.

* **Tests**
  * Added coverage for RoPE configuration export, Qwen3-VL position IDs, and vision-language training options.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

@skierat
skierat requested review from a team as code owners July 14, 2026 14:02
@skierat
skierat requested review from h-guo18 and shengliangxu July 14, 2026 14:02
@copy-pr-bot

copy-pr-bot Bot commented Jul 14, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 11691ebe-87df-4f7f-be7f-9f4706cc3289

📥 Commits

Reviewing files that changed from the base of the PR and between f479e78 and 7b7c17b.

📒 Files selected for processing (6)
  • examples/speculative_decoding/eagle_utils.py
  • modelopt/torch/export/plugins/hf_spec_export.py
  • modelopt/torch/speculative/plugins/hf_dflash.py
  • tests/unit/torch/export/test_hf_spec_rope_export.py
  • tests/unit/torch/speculative/plugins/test_hf_dflash.py
  • tests/unit/torch/speculative/plugins/test_hf_speculative_offline.py

📝 Walkthrough

Walkthrough

Changes

DFlash and RoPE updates

Layer / File(s) Summary
RoPE configuration extraction
modelopt/torch/export/plugins/hf_spec_export.py, tests/unit/torch/export/test_hf_spec_rope_export.py
RoPE export now reads legacy and nested configuration fields, with coverage for rope_parameters.
Qwen3-VL DFlash training flow
modelopt/torch/speculative/plugins/hf_dflash.py, tests/unit/torch/speculative/plugins/test_hf_dflash.py
Qwen3-VL video grids and position ids are validated and expanded; multimodal forward calls, loss masks, and empty-anchor gradients are updated.
Vision-language data wiring
examples/speculative_decoding/eagle_utils.py, tests/unit/torch/speculative/plugins/test_hf_speculative_offline.py
The online VLM collator receives answer-only loss, label-shifting, and chat-template settings, with corresponding test coverage.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers: h-guo18, yeyu-nvidia

Sequence Diagram(s)

sequenceDiagram
  participant HFDFlashModel
  participant Qwen3VLModel
  participant get_rope_index
  HFDFlashModel->>Qwen3VLModel: recompute position_ids from multimodal inputs
  Qwen3VLModel->>get_rope_index: pass expanded video_grid_thw and mm_token_type_ids
  get_rope_index-->>HFDFlashModel: return position_ids
  HFDFlashModel->>Qwen3VLModel: forward multimodal inputs
  Qwen3VLModel-->>HFDFlashModel: return hidden_states
Loading
🚥 Pre-merge checks | ✅ 6
✅ Passed checks (6 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely matches the main change: adding Qwen3-VL support for DFlash training.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Security Anti-Patterns ✅ Passed No critical anti-patterns were introduced: the only new torch.load(weights_only=False) has an inline safety comment; no hardcoded trust_remote_code/eval/nosec or unsafe numpy.load additions.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@h-guo18

h-guo18 commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

/claude review

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude review — 1 CRITICAL, 0 IMPORTANT, 0 SUGGESTION.

Most impactful finding

[CRITICAL Algorithm] shift_labels crashes the new VLM online path (examples/speculative_decoding/eagle_utils.py:145)

The PR passes shift_labels=shift_labels into VisionLanguageDataCollator(...), but that class (modelopt/torch/utils/plugins/transformers_dataset.py:321) declares (processor, train_len, chat_template, add_generation_prompt, answer_only_loss, local_image_path, return_labels) — no shift_labels parameter and no **kwargs. Any real Qwen3-VL online run therefore fails immediately with a TypeError about an unexpected keyword argument shift_labels.

DFlash specifically requires shift_labels=False (unshifted labels), so this is exactly the value the feature must pass — the crash blocks the PR headline capability end-to-end. The text-only branch (LanguageDataCollator, line 135) is unaffected because that class does declare shift_labels.

The new test_vlm_data_module_passes_dflash_label_mode does not catch this: it swaps VisionLanguageDataCollator for a MagicMock, which accepts arbitrary kwargs and hides the real signature mismatch.

Fix: add shift_labels to VisionLanguageDataCollator.init and forward it to super().init(...) (the parent already stores/uses it). Consider exercising the real collator signature in a test.

Assessment

The RoPE-theta extraction, mRoPE frame-expansion, top-level multimodal forward, loss-mask intersection with attention_mask, and the all-parameter DDP dummy-loss change all look correct and well-reasoned. Risk is concentrated in the single collator signature mismatch, which is a hard crash but a small, contained fix. The PR is also explicitly marked draft pending an e2e smoke test and regression coverage. Blocking on the one CRITICAL.

local_image_path=data_args.vlm_img_dir,
return_labels=True,
answer_only_loss=answer_only_loss,
shift_labels=shift_labels,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CRITICAL Algorithm] VisionLanguageDataCollator does not accept shift_labels, so this call raises TypeError: __init__() got an unexpected keyword argument 'shift_labels' at runtime — crashing the exact VLM online path this PR adds.

VisionLanguageDataCollator.__init__ (modelopt/torch/utils/plugins/transformers_dataset.py:321) is declared as (self, processor, train_len, chat_template, add_generation_prompt, answer_only_loss, local_image_path, return_labels) — it has neither a shift_labels parameter nor **kwargs, and it never forwards shift_labels to super().__init__. The text-only branch above (LanguageDataCollator, line 135) is fine because that class does declare shift_labels, but the VLM subclass does not.

This isn't caught by test_vlm_data_module_passes_dflash_label_mode because that test replaces VisionLanguageDataCollator with a MagicMock, which accepts any kwargs — so the real signature mismatch is masked.

Impact: DFlash for Qwen3-VL requires shift_labels=False, and passing it is the only way to get the unshifted-label behavior DFlash needs — the crash blocks the feature entirely for any real run.

Fix: add shift_labels to VisionLanguageDataCollator.__init__ and forward it to super().__init__(...) (the parent already stores and uses it). For example, in transformers_dataset.py:

def __init__(
    self,
    processor: str,
    train_len: int = 8192,
    chat_template: str | None = None,
    add_generation_prompt: bool = False,
    answer_only_loss: bool = False,
    shift_labels: bool = True,
    local_image_path: str = "",
    return_labels: bool = False,
):
    ...
    super().__init__(
        tokenizer=self.processor.tokenizer,
        train_len=train_len,
        chat_template=chat_template,
        add_generation_prompt=add_generation_prompt,
        answer_only_loss=answer_only_loss,
        shift_labels=shift_labels,
        return_labels=return_labels,
    )

Consider having the test construct the real collator (or assert against its actual signature) so this class of mismatch is caught.

if (
position_ids is not None
or getattr(self.config, "model_type", None) != "qwen3_vl"
or not transformers.__version__.startswith("5.3.")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: is this patch only needed for transformers 5.3, or for all transformers 5.x or >5.3?

@h-guo18 h-guo18 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[AI review] Supplementing the earlier claude[bot] review (which flagged the shift_labels TypeError): a few remaining issues, details inline.

Also noting a test-coverage gap for the pending regression work: nothing currently exercises the multimodal top-level-forward branch, the loss-mask ∩ attention_mask change, or the all-parameter DDP dummy-loss path.

local_image_path=data_args.vlm_img_dir,
return_labels=True,
answer_only_loss=answer_only_loss,
shift_labels=shift_labels,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[AI review] The PR description says VisionLanguageDataCollator was extended (propagating answer_only_loss/chat-template/label-alignment settings, VLM_MIN_PIXELS/VLM_MAX_PIXELS limits, ChatML-boundary assistant masks, fixed training_seq_len enforcement), but modelopt/torch/utils/plugins/transformers_dataset.py is not part of this diff — on the current head the class still has its old signature. It looks like that file may not have been committed/pushed.

Note this is broader than the shift_labels TypeError already flagged: even after adding that one parameter, the described mask/pixel/seq-len behaviors would still be missing from the PR.

if (
position_ids is not None
or getattr(self.config, "model_type", None) != "qwen3_vl"
or not transformers.__version__.startswith("5.3.")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[AI review] Two robustness concerns with this gate:

  1. Version pin. Tracing transformers 5.3.0, the per-video vs per-frame grid mismatch looks like a 5.3 bug rather than a stable contract: get_rope_index consumes one grid row per rendered visual group (next(grid_iters[...])), while the processor renders T timestamp-separated frame groups per video and the video processor emits one [T, H, W] row per video. Even the vanilla generation path (_prepare_position_ids_for_generation) passes the raw grid, so upstream video inference appears equally broken on 5.3 — meaning upstream will likely fix this inside get_rope_index (as in 4.x, where the expansion was internal). If that fix lands in a 5.3.x patch release, this external expansion becomes a double expansion and silently corrupts positions; on 5.4+ the helper silently disables itself and multimodal batches fall back to the broken internal path. Suggest linking the upstream issue/commit here, and failing loudly (frame-group count ≠ grid-row count) instead of silently returning None when the gate doesn't match.

  2. model_type != "qwen3_vl" exact match. Family variants like qwen3_vl_moe skip the precomputation but still enter the multimodal top-level forward below — landing exactly in the broken path this helper works around. Consider covering the family or at least warning.

- Label alignment: position k predicts token at anchor+k
- Optional loss decay weighting
"""
position_ids = self._qwen3_vl_position_ids(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[AI review] This runs before the if not self.training branch, so it also fires on eval prefill (multimodal inputs, no cache). Passing explicit position_ids makes Qwen3VLModel.forward skip compute_3d_position_ids, so self.rope_deltas is never established; a subsequent cached decode step (position_ids=None, past_key_values not None → this helper returns None) then falls into the rope_deltas-based branch with a stale/None delta and computes wrong positions.

Since the docstring says this construction is only needed for DFlash training, consider gating on self.training.

attention_mask=attention_mask,
output_hidden_states=True,
if use_top_level_forward:
base_forward_kwargs = dict(kwargs)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[AI review] base_forward_kwargs forwards everything except assistant_masks/loss_mask. Anything else that reaches forward's **kwargs — e.g. Trainer-injected num_items_in_batch (Trainer detects that this forward accepts **kwargs and adds loss kwargs), or stray dataset columns — gets passed to the HF multimodal forward and can raise TypeError or be silently swallowed depending on version. The text-only branch below is immune because it forwards a fixed argument set.

Consider an allowlist (the multimodal keys + known model kwargs) or filtering against the base forward's signature.

import logging

import torch
import transformers

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[AI review] nit: import transformers lands between import torch and import torch.nn.functional as F; ruff/isort (I001) will flag this in pre-commit/CI.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants